#!/usr/bin/perl
#
# timedog
#
#   J.D. Smith (jdtsmith A@T gmail _d0t_ com)
#
# Display the files which time machine backed up in its most recent
# backup (or any of your choosing).  Uses the fact that Time Machine
# creates hard links for unchanged files and directories.  Reports old
# and new sizes of the changed files, along with the total file count
# and backup size.  The string "...." denotes files created or deleted.
#
# Usage:  timedog [-t] [-s] [-d depth] [-l] [-n] [-m size] [timestamp]
#
#   N.B. By default, timedog locates your Time Machine directory for
#   you using tmutil.  You must first ensure the Time Machine volume
#   is mounted.  The Time Machine directory can be found in
#   /Volumes/TM/Backups.backupdb/hostname or similar.
#
#     timestamp: The backup directory for which you'd like to see the
#             changed contents.  Defaults to the most recent (the one
#             linked to by Latest).
#
#         -t: Instead of comparing backups, show a list of the
#             available backup timestamps which could be used for the
#             'timestamp' argument.
#
#         -l: Omit symbolic links from the summary.  For whatever
#             reason, Time Machine creates a new version of all
#             symbolic links each and every time it backs up.
#
#         -n: Use simple fixed width formatting (useful for
#             spreadsheet or other parsing), and omit summaries.
#
#   -d depth: By default, all files are printed, which can get
#             lengthy.  With this option, summarize changes in
#             directories only down to the given depth.  The number of
#             files and subdirectories which changed will be reported
#             as [n], after the before and after sizes for those
#             changed files.
#
#         -s: Sort results by (current) size.
#
#    -m size: Omit from mention any file, or combined directory of
#             files, smaller than size (bytes by default, but can
#             be specified as K, M, G, T for kilo-, mega-, giga-, tera-
#             bytes).
#
# Example:
#
#   % timedog -d 5 -m 1k -ls
#
# Acknowledgements:
#
#    Nathan Fielder: Packaging and google code repo
#    Lanny Rosicky: tmutil directory locator
#
###############################################################################
# $Id$
###############################################################################
# 
# LICENSE
#
#  Copyright (C) 2008, 2009, 2013 J.D. Smith
#
#  This file is free software; you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published
#  by the Free Software Foundation; either version 2, or (at your
#  option) any later version.
#
#  This File is distributed in the hope that it will be useful, but
#  WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
#  General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this file; see the file COPYING.  If not, write to the
#  Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
#  Boston, MA 02110-1301, USA.
#
###############################################################################


use File::Find;
use Fcntl ':mode';
use Getopt::Std;
use Cwd;

getopts('tlnsd:m:');

sub bytes {
  my $bytes=shift;
  $format=shift || ".1";
  @suff=("B","KB","MB","GB","TB");
  for ($suff=shift @suff; $#suff>=0 and $bytes>=1000.; $suff=shift @suff) {
    $bytes/=1024.;
  }
  return int($bytes) . $suff if int($bytes)==$bytes;
  return sprintf("%${format}f",$bytes) . $suff;
}


# Summarize a changed file
sub summarize {
  ($size,$size_old,$old_exists,$name,$cnt)=@_;
  return if $opt_m && $size<$opt_m;
  if ($opt_n) {
    push @summary,
      [$size,sprintf("%12d %12d %s\n",$old_exists?$size_old:0,$size,$name)];
  } else {
    if ($opt_d) {
      push @summary,
	[$size,sprintf("%9s->%9s %6s %s\n",$old_exists?bytes($size_old):".... ",
		       bytes($size),$cnt?"[$cnt]":"",$name)];
    } else {
      push @summary,
	[$size,sprintf("%9s->%9s %s\n",$old_exists?bytes($size_old):".... ",
		       bytes($size),$name)];
    }
  }
}

# When no machinedirectory is found, check if local snapshots are enabled
chomp (my $tmdir=`tmutil machinedirectory 2>/dev/null || ls -d1 /Volumes/MobileBackups/Backups.backupdb/*`);
(-d $tmdir && chdir $tmdir) ||
  die "Cannot locate timemachine directory or local snapshot ($tmdir)";

$isLocalSnap=($tmdir=~/^.Volumes.MobileBackups/);

opendir DIR,"." or die "Can't open directory.";
@files=sort grep {m|[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]+$|} readdir(DIR);
die "None or only one Time Machine backups found." if @files == 1;

if ($opt_t) {
  print join("\n",@files),"\n";
  exit;
}
    

if (@ARGV) {
  $latest=$ARGV[0];
  $latest=~s|/$||;
  foreach (@files) {
    last if $_ eq $latest;
    $last=$_;
  }
  die "Invalid backup directory" if !defined($last) || $last eq $latest;
} else {
  ($last,$latest)=@files[$#files-1..$#files];
}

print "==> Comparing TM backup $latest to $last\n" unless $opt_n;

my ($old_exists,$rold_exists,$rsize,$rsize_old);
$total_size=0;
$total_cnt=0;

%conv=('k' => 1024, 'm' => 1024**2, 'g'=>1024**3, 't'=>1024**4);
if ($opt_m){
  $opt_m=~/([0-9.]+)([kmgt]?)/i;
  $opt_m=$1;
  $opt_m*=$conv{lc $2} if $2;
}

unless ($opt_n) {
  if ($opt_d) {
    print "    Depth: $opt_d directories  "
  }
  if ($opt_m) {
    print "Omitting if smaller than: ",bytes($opt_m),"\n";
  } else { print "\n" if $opt_d;}
}
    
find({wanted =>
      sub{
	($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size) =
	  lstat($_);
	($old=$_)=~s/^$latest/$last/;
	if (-e $old) {
	  ($dev, $ino_old,$mode_old,
	   $nlink,$uid,$gid,$rdev,$size_old) = lstat($old);
	  if ($ino == $ino_old) { # Prune matching
	    $File::Find::prune=1 if (-d && (!$isLocalSnap || index($File::Find::dir,'/')>0));
	    return
	  }
	  $old_exists=1;
	} else {$old_exists=0;}


	$total_size+=$size;

	$link=S_ISLNK($mode);
	return if $opt_l && $link;

	# Don't include links in the count
	$total_cnt++;
	($name=$_)=~s/^$latest//;

	if ($opt_d) {
	  $depth=$name=~tr|/||;
	  $rsize+=$size;
	  $rsize_old+=$size_old if $old_exists;
	  $rcnt++;
	  return if S_ISDIR($mode) || $depth > $opt_d; # Post will handle
	}
	$name.="/" if S_ISDIR($mode);
	$name.="@" if $link;
	summarize($size,$size_old,$old_exists,$name);
      },
      preprocess =>
      (!$opt_d)?0:
      sub{
	$depth=$File::Find::dir=~tr|/||;
	if ($depth<=$opt_d) {
	  # Starting a new printable directory; zero out sizes
	  $rsize=$rsize_old=$rcnt=0;
	  $rold_exists=-e $File::Find::dir;
	}
	@_;
      },
      postprocess =>
      (!$opt_d)?0:
      sub{
	$depth=$File::Find::dir=~tr|/||;
	return if $depth > $opt_d;
	# This directory is at or below the depth, summarize it
	($name=$File::Find::dir)=~s/^$latest//;
	summarize($rsize,$rsize_old,$rold_exists,$name.'/',$rcnt)
	  if $rsize || $rsize_old;
	$rsize=$rsize_old=$rcnt=0;
      },
      no_chdir => 1}, $latest);

if ($opt_s) {
  foreach (map {$_->[1]} sort {$a->[0] <=> $b->[0]} @summary) { print;}
} else {
  foreach (map {$_->[1]} @summary) { print;}
}

print "==> Total Backup: $total_cnt changed files/directories, ",
  bytes($total_size,".2"),"\n" unless $opt_n;
